💡 今日學習目標:理解 SSRF 的攻擊原理與雲端環境下的致命後果,看懂為什麼「先解析 DNS 再檢查 IP」這個看起來很嚴謹的防線其實有漏洞,並掌握程式碼層、雲端層、網路層三道防守。
2021 年,SSRF 以 A10 的身分獨立成為一個類別,那次它是靠社群問卷投票空降進榜的,因為業界普遍認為它的危險程度被低估了。
四年後的 2025 年,你在榜單上找不到 SSRF 了。
它沒有消失,而是被併進了 A01 存取控制失效:CWE-918: Server-Side Request Forgery 現在被列在 A01 的重點 CWE 清單裡,和 CWE-200、CWE-352 (CSRF) 並列。
OWASP 官方頁面沒有解釋合併的理由。所以以下是筆者的理解,你可以自己判斷合不合理:
存取控制失效的定義是「能做到超出被授權範圍的事」。而 SSRF 的本質是:伺服器被誘導去存取一個它「有能力存取、但這個請求不該存取」的資源。
差別只在於越權的主體不是使用者本人,而是被當成代理人的伺服器。攻擊者自己碰不到內網,但你的伺服器碰得到,於是他借用你的手。
而從防守角度看,兩者的心法完全一致:不要讓使用者的輸入直接決定「要存取哪個資源」。這句話你在 Day 16 的 IDOR、Day 18 的排序欄位白名單都看過了,今天是同一句話的第三次出場。
理解這個歸類,比背下防禦手法更有價值。因為它告訴你:這三種看起來完全不同的漏洞,其實是同一個病。
先講一個真實案例,你就知道為什麼這個漏洞值得認真對待。
2019 年 3 月 22 至 23 日,美國 Capital One 銀行遭入侵,約 1.06 億筆信用卡申請人的個資外流(美國約 1 億筆、加拿大約 600 萬筆)。
特別注意這個時間差:入侵發生在 3 月,但 Capital One 一直到 7 月 17 日(被一位 GitHub 使用者通報後)才知道自己出事了。中間整整四個月,攻擊者來去自如。
攻擊路徑非常簡潔:
1. 攻擊者找到一台對外的 WAF 主機上的 SSRF 漏洞
2. 透過它去存取 http://169.254.169.254/latest/meta-data/iam/security-credentials/
(AWS 的 Instance Metadata Service,每台 EC2 都能存取的內部服務)
3. 拿到了那台主機 IAM 角色的臨時憑證
4. 而那個 IAM 角色的權限開得太大,攻擊者用它列出並下載了 700 多個 S3 儲存桶
注意第 4 步:如果那個 IAM 角色遵守了最小權限原則(Day 14 SEI 法則 6),這起事件的規模會小非常多。SSRF 打開了門,但真正決定損失有多大的,是門後面那把鑰匙的權限。
📖 資料來源:美國司法部起訴書新聞稿(入侵日期與 S3 儲存桶數量出自此份起訴文件);Wiz Cloud Threat Landscape — Capital One Incident (March 2019)(攻擊鏈與時間軸整理)
🧭 這個案子明天還會再出現一次。 今天我們看的是「SSRF 這個漏洞本身怎麼被利用」;Day 22 會從架構的角度重看同一起事件,問一個不同的問題:為什麼一個漏洞可以被放大成一億筆?
// ❌ 錯誤範例:完全信任外部傳入的 URL
Function ProxyFetchImage(userProvidedUrl):
Response = HttpClient.Get(userProvidedUrl)
Return ImageResponse(Response.Bytes)
這種程式碼在真實專案裡到處都是:抓使用者頭像、產生網址預覽圖、呼叫 Webhook、匯入遠端檔案。功能都很正常,直到有人把 URL 換成內網位址。
新手第一直覺是:那我把 127.0.0.1 和 169.254.169.254 過濾掉不就好了?
以下每一個都會指向本機,而且都繞過了那個字串比對:
| 繞過手法 | 範例 |
|---|---|
| 十進位 IP | http://2130706433/ |
| 八進位 / 十六進位 | http://0177.0.0.1/、http://0x7f000001/ |
| 省略寫法 | http://127.1/、http://0/ |
| IPv6 環回與對映 | http://[::1]/、http://[::ffff:127.0.0.1]/ |
| 網址帳號欄位 | http://允許的網域@169.254.169.254/ |
| 攻擊者自己的 DNS | 註冊一個 A 記錄直接指向 169.254.169.254 |
| 302 轉址 | 一個通過檢查的網址,回應時把你導向內網 |
🔑 這張表想說的不是「你要記住這七種寫法」,而是:黑名單永遠列不完。這是 Day 13 SEI 法則講過的:驗證輸入要用白名單,不要用黑名單。你能想到的繞過方式,永遠比攻擊者少一種。
所以正確方向是:解析出真正的 IP,然後判斷它落在哪個網段。判斷「是不是私有網段」是一個封閉的、可窮舉的問題,而「有沒有漏掉哪種寫法」不是。
網路上絕大多數的 SSRF 防禦範例,都是這樣寫的:
// ⚠️ 看起來很嚴謹,但有漏洞
targetIP = DNS.Resolve(parsedUrl.Host) // 先解析
If IsPrivateOrInternalIP(targetIP): // 再檢查
Return Error(403)
Return HttpClient.Get(userProvidedUrl) // 🚨 問題在這一行
看出來了嗎?你檢查的是 IP,但你最後拿去連線的是「網域」。
於是 HTTP 函式庫會自己再解析一次 DNS,而那是一次全新的查詢。

攻擊者只要把自己網域的 DNS 記錄 TTL 設成 0,就能讓這兩次查詢回傳不同的答案:檢查時給你一個乾淨的公網 IP,連線時換成 169.254.169.254。這就是 DNS Rebinding。
🎯 這是典型的 TOCTOU(Time-of-Check to Time-of-Use)問題:檢查的那一刻與使用的那一刻之間,狀態被換掉了。
記住這個原則,它適用的範圍遠遠不只 SSRF:「你檢查的那個對象」與「你實際使用的那個對象」必須是同一個,否則檢查等於沒做。
優先選項:如果你的需求允許,用網域白名單。
// ✅ 最佳解:目標是固定的幾個服務
ALLOWED_HOSTS = { "api.partner.com", "cdn.trusted.com" }
If NOT ALLOWED_HOSTS.Contains(parsedUrl.Host):
Return Error(403)
很多團隊直接跳過這一步去做複雜的 IP 判斷,但其實大部分場景的目標網域是可以列舉的。能白名單就白名單,這是成本最低、最不會出錯的做法。
如果真的必須接受任意網址(例如網址預覽功能),那就要做完整版:
Function SafeFetch(userProvidedUrl):
url = ParseUrl(userProvidedUrl)
// 1. 協定白名單:只准 http/https
// 否則 file:// 可以讀本機檔案、gopher:// 可以打內網 Redis
If url.Scheme NOT IN ["http", "https"]:
Return Error(403, "Protocol not allowed")
// 2. 解析出所有 IP(一個網域可能有多筆 A 記錄,每一筆都要檢查)
ips = DNS.ResolveAll(url.Host)
For each ip in ips:
If IsPrivateOrInternalIP(ip):
Return Error(403, "Internal address blocked")
// 3. 【關鍵】連線到「剛剛檢查過的那個 IP」,不要再交給函式庫解析
Return HttpClient
.ConnectTo(ips[0]) // 直接指定 IP
.WithHeader("Host", url.Host) // 手動補回 Host 標頭
.WithTlsServerName(url.Host) // HTTPS 的 SNI 也要指定,否則憑證驗證會失敗
.DisableRedirects() // 4. 不自動跟隨轉址
.Get(url.Path)
IsPrivateOrInternalIP() 至少要涵蓋:127.0.0.0/8、10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、169.254.0.0/16(含 Metadata)、0.0.0.0/8,以及 IPv6 的 ::1、fc00::/7、fe80::/10。
⚠️ 關於第 4 點的轉址:如果你的功能真的需要跟隨轉址,那就每一跳都要重新跑一次上面的檢查,不能只驗第一個網址。
Capital One 事件之後,AWS 推出了 IMDSv2 來從根本上防這一招。它的機制是:
# 第 1 步:用 PUT 取得一張 token(注意:是 PUT,而且要帶自訂標頭)
TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
# 第 2 步:之後每次查詢都要帶著這張 token
curl -H "X-aws-ec2-metadata-token: $TOKEN" \
http://169.254.169.254/latest/meta-data/
為什麼這樣就擋得住? 因為絕大多數 SSRF 只能讓伺服器發出簡單的 GET 請求,沒辦法指定 HTTP 方法、也沒辦法塞自訂標頭。IMDSv2 同時要求這兩件事,等於把最常見的 SSRF 利用鏈直接切斷。
另外兩個附加保護:預設的回應跳躍限制(hop limit)是 1,讓請求無法穿過容器或反向代理層抵達 IMDS;而且帶有 X-Forwarded-For 標頭的 PUT 請求會被直接拒絕,那個標頭正是反向代理會自動加上的。
⚠️ 但要注意:依 AWS 文件,預設是 IMDSv1 與 IMDSv2 兩者都接受。你必須主動把它設成
required,IMDSv1 才會真的關閉。這件事不會自己發生。GCP 與 Azure 也有對應的機制(要求帶Metadata-Flavor或Metadata標頭),原理相同。
最後一道,也是最不依賴開發者記得寫檢查的一道:出向流量限制(Egress Filtering)。
把會對外抓取資料的服務放進一個獨立的子網路,在防火牆規則上直接禁止它連往 RFC1918 私有網段與 169.254.0.0/16。或者強制它所有的外連都走一台專用 Proxy,由 Proxy 統一做前面那些檢查。
🛡️ 這正是 Day 14 縱深防禦的實例:程式碼那層可能被新來的同事改壞、可能有你沒想到的繞過方式,但網路層的規則是跟程式碼無關的。三層獨立,攻擊者要三層都突破才會成功。
到今天為止,我們完成了 階段三:資安是製造出來的(Day 12 - Day 21)。
這十天我們做了三件事:
回頭看,你會發現這六篇的心法其實高度重疊:分離結構與資料、白名單而非黑名單、預設拒絕、最小權限。這不是巧合:十大類別是「症狀」的分類,而防守法則是「病因」的分類,所以少數幾條法則可以蓋掉大部分症狀。
階段三教的是「打字敲 Code 時把它寫對」。但有些問題,寫得再對也救不回來,因為問題出在架構決定的那一刻。
從 Day 22 開始,我們把視角從程式碼拉高到系統架構,進入威脅建模(STRIDE)的世界。
💬 明日預告:【Day 22】駭客只需要成功一次,我們必須每次都成功:安全架構設計思維
明天我們開啟階段四,談 Security by Design,以及一個很多系統都踩過的坑:當系統出錯時,它應該預設「放行」還是「拒絕」?